Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit ea8ef555c260fe3d201c254b37561818c985ed5d


Parents : bf94cee
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-01T20:40:50-06:00

feat(contacts): implement contact management features including add, update, delete, and search functionality; enhance UI with contact sharing capabilities and integrate with existing telephone features

Changes
Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 9b9db86b..7be3946d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -57,13 +57,13 @@ from meshchatx.src.backend.lxmf_message_fields import (
)
from meshchatx.src.backend.map_manager import MapManager
from meshchatx.src.backend.message_handler import MessageHandler
+from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.rncp_handler import RNCPHandler
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.telemetry_utils import Telemeter
from meshchatx.src.backend.telephone_manager import TelephoneManager
-from meshchatx.src.backend.ringtone_manager import RingtoneManager
from meshchatx.src.backend.translator_handler import TranslatorHandler
from meshchatx.src.backend.voicemail_manager import VoicemailManager
from meshchatx.src.version import __version__ as app_version
@@ -186,13 +186,13 @@ class ReticulumMeshChat:
self.auto_recover = auto_recover
self.auth_enabled_initial = auth_enabled
self.websocket_clients: list[web.WebSocketResponse] = []
-
+
# track announce timestamps for rate calculation
self.announce_timestamps = []
-
+
# track download speeds for nomadnetwork files
self.download_speeds = []
-
+
# track active downloads
self.active_downloads = {}
self.download_id_counter = 0
@@ -201,7 +201,7 @@ class ReticulumMeshChat:
def setup_identity(self, identity: RNS.Identity):
# assign a unique session ID to this identity instance to help background threads exit
- if not hasattr(self, '_identity_session_id'):
+ if not hasattr(self, "_identity_session_id"):
self._identity_session_id = 0
self._identity_session_id += 1
session_id = self._identity_session_id
@@ -215,8 +215,8 @@ class ReticulumMeshChat:
print(f"Using Storage Path: {self.storage_path}")
os.makedirs(self.storage_path, exist_ok=True)
- # Safety: Before setting up a new identity, ensure no destinations for this identity
- # are currently registered in Transport. This prevents the "Attempt to register
+ # Safety: Before setting up a new identity, ensure no destinations for this identity
+ # are currently registered in Transport. This prevents the "Attempt to register
# an already registered destination" error during hotswap.
self.cleanup_rns_state_for_identity(identity.hash)
@@ -275,7 +275,7 @@ class ReticulumMeshChat:
self.database.messages.mark_stuck_messages_as_failed()
# init reticulum
- if not hasattr(self, 'reticulum'):
+ if not hasattr(self, "reticulum"):
self.reticulum = RNS.Reticulum(self.reticulum_config_dir)
self.identity = identity
@@ -606,7 +606,7 @@ class ReticulumMeshChat:
def cleanup_rns_state_for_identity(self, identity_hash):
if not identity_hash:
return
-
+
if isinstance(identity_hash, str):
identity_hash_bytes = bytes.fromhex(identity_hash)
identity_hash_hex = identity_hash
@@ -622,10 +622,10 @@ class ReticulumMeshChat:
for destination in list(RNS.Transport.destinations):
match = False
# check identity hash
- if hasattr(destination, 'identity') and destination.identity:
+ if hasattr(destination, "identity") and destination.identity:
if destination.identity.hash == identity_hash_bytes:
match = True
-
+
if match:
print(f"Deregistering RNS destination {destination} ({RNS.prettyhexrep(destination.hash)})")
RNS.Transport.deregister_destination(destination)
@@ -637,11 +637,11 @@ class ReticulumMeshChat:
for link in list(RNS.Transport.active_links):
match = False
# check if local identity or destination matches
- if hasattr(link, 'destination') and link.destination:
- if hasattr(link.destination, 'identity') and link.destination.identity:
+ if hasattr(link, "destination") and link.destination:
+ if hasattr(link.destination, "identity") and link.destination.identity:
if link.destination.identity.hash == identity_hash_bytes:
match = True
-
+
if match:
print(f"Tearing down RNS link {link}")
try:
@@ -654,27 +654,27 @@ class ReticulumMeshChat:
def teardown_identity(self):
print("Tearing down current identity instance...")
self.running = False
-
+
# 1. Deregister destinations and links from RNS Transport
try:
# Get current identity hash for matching
- current_identity_hash = self.identity.hash if hasattr(self, 'identity') and self.identity else None
-
+ current_identity_hash = self.identity.hash if hasattr(self, "identity") and self.identity else None
+
# Explicitly deregister known destinations from managers first
- if hasattr(self, 'message_router') and self.message_router:
+ if hasattr(self, "message_router") and self.message_router:
# Deregister delivery destinations
- if hasattr(self.message_router, 'delivery_destinations'):
+ if hasattr(self.message_router, "delivery_destinations"):
for dest_hash in list(self.message_router.delivery_destinations.keys()):
dest = self.message_router.delivery_destinations[dest_hash]
RNS.Transport.deregister_destination(dest)
-
+
# Deregister propagation destination
- if hasattr(self.message_router, 'propagation_destination') and self.message_router.propagation_destination:
+ if hasattr(self.message_router, "propagation_destination") and self.message_router.propagation_destination:
RNS.Transport.deregister_destination(self.message_router.propagation_destination)
- if hasattr(self, 'telephone_manager') and self.telephone_manager:
- if hasattr(self.telephone_manager, 'telephone') and self.telephone_manager.telephone:
- if hasattr(self.telephone_manager.telephone, 'destination') and self.telephone_manager.telephone.destination:
+ if hasattr(self, "telephone_manager") and self.telephone_manager:
+ if hasattr(self.telephone_manager, "telephone") and self.telephone_manager.telephone:
+ if hasattr(self.telephone_manager.telephone, "destination") and self.telephone_manager.telephone.destination:
RNS.Transport.deregister_destination(self.telephone_manager.telephone.destination)
# Use the global helper for thorough cleanup
@@ -688,53 +688,47 @@ class ReticulumMeshChat:
try:
for handler in list(RNS.Transport.announce_handlers):
should_deregister = False
-
+
# check if it's one of our AnnounceHandler instances
- if hasattr(handler, 'aspect_filter') and hasattr(handler, 'received_announce_callback'):
+ if (hasattr(handler, "aspect_filter") and hasattr(handler, "received_announce_callback")) or (hasattr(handler, "router") and hasattr(self, "message_router") and handler.router == self.message_router) or "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)):
should_deregister = True
- # LXMF handlers - they usually have a reference to the router
- elif hasattr(handler, 'router') and hasattr(self, 'message_router') and handler.router == self.message_router:
- should_deregister = True
- # generic check for LXMF handlers if the above fails
- elif "LXMFDeliveryAnnounceHandler" in str(type(handler)) or "LXMFPropagationAnnounceHandler" in str(type(handler)):
- should_deregister = True
-
+
if should_deregister:
RNS.Transport.deregister_announce_handler(handler)
except Exception as e:
print(f"Error while deregistering announce handlers: {e}")
# 3. Stop the LXMRouter job loop (hacking it to stop)
- if hasattr(self, 'message_router') and self.message_router:
+ if hasattr(self, "message_router") and self.message_router:
try:
# Replacing jobs with a no-op so the thread just sleeps
self.message_router.jobs = lambda: None
-
+
# Try to call exit_handler to persist state
- if hasattr(self.message_router, 'exit_handler'):
+ if hasattr(self.message_router, "exit_handler"):
self.message_router.exit_handler()
except Exception as e:
print(f"Error while tearing down LXMRouter: {e}")
# 4. Stop telephone and voicemail
- if hasattr(self, 'telephone_manager') and self.telephone_manager:
+ if hasattr(self, "telephone_manager") and self.telephone_manager:
try:
# use teardown instead of shutdown
- if hasattr(self.telephone_manager, 'teardown'):
+ if hasattr(self.telephone_manager, "teardown"):
self.telephone_manager.teardown()
- elif hasattr(self.telephone_manager, 'shutdown'):
+ elif hasattr(self.telephone_manager, "shutdown"):
self.telephone_manager.shutdown()
except Exception as e:
print(f"Error while tearing down telephone: {e}")
-
- if hasattr(self, 'voicemail_manager') and self.voicemail_manager:
+
+ if hasattr(self, "voicemail_manager") and self.voicemail_manager:
try:
self.voicemail_manager.stop_recording()
except Exception:
pass
# 5. Close database
- if hasattr(self, 'database') and self.database:
+ if hasattr(self, "database") and self.database:
try:
self.database.close()
except Exception:
@@ -747,33 +741,33 @@ class ReticulumMeshChat:
identity_file = os.path.join(identity_dir, "identity")
if not os.path.exists(identity_file):
raise ValueError("Identity file not found")
-
+
new_identity = RNS.Identity.from_file(identity_file)
-
+
# 1. teardown old identity
self.teardown_identity()
-
+
# Wait a moment for threads to notice self.running=False and destinations to clear
await asyncio.sleep(3)
-
+
# 2. update main identity file
main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
import shutil
shutil.copy2(identity_file, main_identity_file)
-
+
# 3. reset state and setup new identity
self.running = True
self.setup_identity(new_identity)
-
+
# 4. broadcast update to clients
await self.websocket_broadcast(
json.dumps({
"type": "identity_switched",
"identity_hash": identity_hash,
- "display_name": self.config.display_name.get()
- })
+ "display_name": self.config.display_name.get(),
+ }),
)
-
+
return True
except Exception as e:
print(f"Hotswap failed: {e}")
@@ -818,12 +812,12 @@ class ReticulumMeshChat:
icon_name = None
icon_foreground_colour = None
icon_background_colour = None
-
+
try:
# use a temporary provider to avoid messing with current DB
- from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.config import ConfigDAO
-
+ from meshchatx.src.backend.database.provider import DatabaseProvider
+
temp_provider = DatabaseProvider(db_path)
temp_config_dao = ConfigDAO(temp_provider)
display_name = temp_config_dao.get("display_name", "Anonymous Peer")
@@ -840,49 +834,49 @@ class ReticulumMeshChat:
"icon_name": icon_name,
"icon_foreground_colour": icon_foreground_colour,
"icon_background_colour": icon_background_colour,
- "is_current": identity_hash == self.identity.hash.hex()
+ "is_current": identity_hash == self.identity.hash.hex(),
})
return identities
def create_identity(self, display_name=None):
new_identity = RNS.Identity(create_keys=True)
identity_hash = new_identity.hash.hex()
-
+
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
os.makedirs(identity_dir, exist_ok=True)
-
+
# save identity file in its own directory
identity_file = os.path.join(identity_dir, "identity")
with open(identity_file, "wb") as f:
f.write(new_identity.get_private_key())
-
+
# initialize its database and set display name
db_path = os.path.join(identity_dir, "database.db")
-
+
# Avoid using the Database class singleton behavior
+ from meshchatx.src.backend.database.config import ConfigDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
- from meshchatx.src.backend.database.config import ConfigDAO
-
+
new_provider = DatabaseProvider(db_path)
new_schema = DatabaseSchema(new_provider)
new_schema.initialize()
-
+
if display_name:
new_config_dao = ConfigDAO(new_provider)
new_config_dao.set("display_name", display_name)
-
+
new_provider.close()
-
+
return {
"hash": identity_hash,
- "display_name": display_name or "Anonymous Peer"
+ "display_name": display_name or "Anonymous Peer",
}
def delete_identity(self, identity_hash):
if identity_hash == self.identity.hash.hex():
raise ValueError("Cannot delete the current active identity")
-
+
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
if os.path.exists(identity_dir):
import shutil
@@ -1454,10 +1448,10 @@ class ReticulumMeshChat:
# session secret for encrypted cookies (generate once and store in shared storage)
session_secret_path = os.path.join(self.storage_dir, "session_secret")
self.session_secret_key = None
-
+
if os.path.exists(session_secret_path):
try:
- with open(session_secret_path, "r") as f:
+ with open(session_secret_path) as f:
self.session_secret_key = f.read().strip()
except Exception as e:
print(f"Failed to read session secret from {session_secret_path}: {e}")
@@ -1467,7 +1461,7 @@ class ReticulumMeshChat:
self.session_secret_key = self.config.auth_session_secret.get()
if not self.session_secret_key:
self.session_secret_key = secrets.token_urlsafe(32)
-
+
try:
with open(session_secret_path, "w") as f:
f.write(self.session_secret_key)
@@ -1529,7 +1523,7 @@ class ReticulumMeshChat:
is_authenticated = session.get("authenticated", False)
session_identity = session.get("identity_hash")
-
+
# Check if authenticated AND matches current identity
if not is_authenticated or session_identity != self.identity.hash.hex():
if path.startswith("/api/"):
@@ -1572,10 +1566,10 @@ class ReticulumMeshChat:
session = await get_session(request)
is_authenticated = session.get("authenticated", False)
session_identity = session.get("identity_hash")
-
+
# Verify that authentication is for the CURRENT active identity
actually_authenticated = is_authenticated and (session_identity == self.identity.hash.hex())
-
+
return web.json_response(
{
"auth_enabled": self.auth_enabled,
@@ -2858,13 +2852,12 @@ class ReticulumMeshChat:
"message": "Identity deleted successfully",
},
)
- else:
- return web.json_response(
- {
- "message": "Identity not found",
- },
- status=404,
- )
+ return web.json_response(
+ {
+ "message": "Identity not found",
+ },
+ status=404,
+ )
except Exception as e:
return web.json_response(
{
@@ -2878,10 +2871,10 @@ class ReticulumMeshChat:
try:
data = await request.json()
identity_hash = data.get("identity_hash")
-
+
# attempt hotswap first
success = await self.hotswap_identity(identity_hash)
-
+
if success:
return web.json_response(
{
@@ -2889,36 +2882,35 @@ class ReticulumMeshChat:
"hotswapped": True,
},
)
- else:
- # fallback to restart if hotswap failed
- # (this part should probably be unreachable if hotswap is reliable)
- main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
- identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
- identity_file = os.path.join(identity_dir, "identity")
- import shutil
- shutil.copy2(identity_file, main_identity_file)
-
- def restart():
- import sys
- import time
- import os
- time.sleep(1)
- try:
- os.execv(sys.executable, [sys.executable] + sys.argv)
- except Exception as e:
- print(f"Failed to restart: {e}")
- os._exit(0)
-
- import threading
- threading.Thread(target=restart).start()
+ # fallback to restart if hotswap failed
+ # (this part should probably be unreachable if hotswap is reliable)
+ main_identity_file = self.identity_file_path or os.path.join(self.storage_dir, "identity")
+ identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
+ identity_file = os.path.join(identity_dir, "identity")
+ import shutil
+ shutil.copy2(identity_file, main_identity_file)
+
+ def restart():
+ import os
+ import sys
+ import time
+ time.sleep(1)
+ try:
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+ except Exception as e:
+ print(f"Failed to restart: {e}")
+ os._exit(0)
- return web.json_response(
- {
- "message": "Identity switch scheduled. Application will restart.",
- "hotswapped": False,
- "should_restart": True,
- },
- )
+ import threading
+ threading.Thread(target=restart).start()
+
+ return web.json_response(
+ {
+ "message": "Identity switch scheduled. Application will restart.",
+ "hotswapped": False,
+ "should_restart": True,
+ },
+ )
except Exception as e:
return web.json_response(
{
@@ -3147,7 +3139,7 @@ class ReticulumMeshChat:
async def telephone_history(request):
limit = int(request.query.get("limit", 10))
history = self.database.telephone.get_call_history(limit=limit)
-
+
call_history = []
for row in history:
d = dict(row)
@@ -3312,9 +3304,11 @@ class ReticulumMeshChat:
# list voicemails
@routes.get("/api/v1/telephone/voicemails")
async def telephone_voicemails(request):
+ search = request.query.get("search")
limit = int(request.query.get("limit", 50))
offset = int(request.query.get("offset", 0))
voicemails_rows = self.database.voicemails.get_voicemails(
+ search=search,
limit=limit,
offset=offset,
)
@@ -3365,12 +3359,12 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/voicemail/greeting/audio")
async def telephone_voicemail_greeting_audio(request):
filepath = os.path.join(
- self.voicemail_manager.greetings_dir, "greeting.opus"
+ self.voicemail_manager.greetings_dir, "greeting.opus",
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
return web.json_response(
- {"message": "Greeting audio not found"}, status=404
+ {"message": "Greeting audio not found"}, status=404,
)
# serve voicemail audio
@@ -3385,6 +3379,7 @@ class ReticulumMeshChat:
)
if os.path.exists(filepath):
return web.FileResponse(filepath)
+ RNS.log(f"Voicemail: Recording file missing for ID {voicemail_id}: {filepath}", RNS.LOG_ERROR)
return web.json_response(
{"message": "Voicemail audio not found"},
status=404,
@@ -3413,7 +3408,7 @@ class ReticulumMeshChat:
field = await reader.next()
if field.name != "file":
return web.json_response(
- {"message": "File field required"}, status=400
+ {"message": "File field required"}, status=400,
)
filename = field.filename
@@ -3472,7 +3467,7 @@ class ReticulumMeshChat:
"created_at": r["created_at"],
}
for r in ringtones
- ]
+ ],
)
@routes.get("/api/v1/telephone/ringtones/status")
@@ -3484,7 +3479,7 @@ class ReticulumMeshChat:
"enabled": self.config.custom_ringtone_enabled.get(),
"filename": primary["filename"] if primary else None,
"id": primary["id"] if primary else None,
- }
+ },
)
@routes.get("/api/v1/telephone/ringtones/{id}/audio")
@@ -3493,7 +3488,7 @@ class ReticulumMeshChat:
ringtone = self.database.ringtones.get_by_id(ringtone_id)
if not ringtone:
return web.json_response({"message": "Ringtone not found"}, status=404)
-
+
filepath = self.ringtone_manager.get_ringtone_path(ringtone["storage_filename"])
if os.path.exists(filepath):
return web.FileResponse(filepath)
@@ -3511,7 +3506,7 @@ class ReticulumMeshChat:
extension = os.path.splitext(filename)[1].lower()
if extension not in [".mp3", ".ogg", ".wav", ".m4a", ".flac"]:
return web.json_response(
- {"message": f"Unsupported file type: {extension}"}, status=400
+ {"message": f"Unsupported file type: {extension}"}, status=400,
)
# Save temp file
@@ -3529,20 +3524,20 @@ class ReticulumMeshChat:
self.ringtone_manager.convert_to_ringtone,
temp_path,
)
-
+
# Add to database
ringtone_id = self.database.ringtones.add(
filename=filename,
- storage_filename=storage_filename
+ storage_filename=storage_filename,
)
-
+
return web.json_response(
{
- "message": "Ringtone uploaded and converted",
+ "message": "Ringtone uploaded and converted",
"id": ringtone_id,
"filename": filename,
- "storage_filename": storage_filename
- }
+ "storage_filename": storage_filename,
+ },
)
finally:
if os.path.exists(temp_path):
@@ -3556,16 +3551,16 @@ class ReticulumMeshChat:
try:
ringtone_id = int(request.match_info["id"])
data = await request.json()
-
+
display_name = data.get("display_name")
is_primary = 1 if data.get("is_primary") else None
-
+
self.database.ringtones.update(
ringtone_id,
display_name=display_name,
- is_primary=is_primary
+ is_primary=is_primary,
)
-
+
return web.json_response({"message": "Ringtone updated"})
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
@@ -3582,6 +3577,60 @@ class ReticulumMeshChat:
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
+ # contacts routes
+ @routes.get("/api/v1/telephone/contacts")
+ async def telephone_contacts_get(request):
+ search = request.query.get("search")
+ limit = int(request.query.get("limit", 100))
+ offset = int(request.query.get("offset", 0))
+ contacts_rows = self.database.contacts.get_contacts(
+ search=search,
+ limit=limit,
+ offset=offset,
+ )
+
+ contacts = []
+ for row in contacts_rows:
+ d = dict(row)
+ remote_identity_hash = d.get("remote_identity_hash")
+ if remote_identity_hash:
+ lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(remote_identity_hash)
+ if lxmf_hash:
+ icon = self.database.misc.get_user_icon(lxmf_hash)
+ if icon:
+ d["remote_icon"] = dict(icon)
+ contacts.append(d)
+
+ return web.json_response(contacts)
+
+ @routes.post("/api/v1/telephone/contacts")
+ async def telephone_contacts_post(request):
+ data = await request.json()
+ name = data.get("name")
+ remote_identity_hash = data.get("remote_identity_hash")
+
+ if not name or not remote_identity_hash:
+ return web.json_response({"message": "Name and identity hash required"}, status=400)
+
+ self.database.contacts.add_contact(name, remote_identity_hash)
+ return web.json_response({"message": "Contact added"})
+
+ @routes.patch("/api/v1/telephone/contacts/{id}")
+ async def telephone_contacts_patch(request):
+ contact_id = int(request.match_info["id"])
+ data = await request.json()
+ name = data.get("name")
+ remote_identity_hash = data.get("remote_identity_hash")
+
+ self.database.contacts.update_contact(contact_id, name, remote_identity_hash)
+ return web.json_response({"message": "Contact updated"})
+
+ @routes.delete("/api/v1/telephone/contacts/{id}")
+ async def telephone_contacts_delete(request):
+ contact_id = int(request.match_info["id"])
+ self.database.contacts.delete_contact(contact_id)
+ return web.json_response({"message": "Contact deleted"})
+
# announce
@routes.get("/api/v1/announce")
async def announce_trigger(request):
@@ -4921,12 +4970,12 @@ class ReticulumMeshChat:
search_query = request.query.get("q", None)
filter_unread = ReticulumMeshChat.parse_bool_query_param(
request.query.get(
- "unread", request.query.get("filter_unread", "false")
+ "unread", request.query.get("filter_unread", "false"),
),
)
filter_failed = ReticulumMeshChat.parse_bool_query_param(
request.query.get(
- "failed", request.query.get("filter_failed", "false")
+ "failed", request.query.get("filter_failed", "false"),
),
)
filter_has_attachments = ReticulumMeshChat.parse_bool_query_param(
@@ -5317,7 +5366,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
- }
+ },
)
return web.json_response({"telemetry": telemetry_list})
@@ -5329,7 +5378,7 @@ class ReticulumMeshChat:
offset = int(request.query.get("offset", 0))
results = self.database.telemetry.get_telemetry_history(
- destination_hash, limit, offset
+ destination_hash, limit, offset,
)
telemetry_list = []
for r in results:
@@ -5343,7 +5392,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
- }
+ },
)
return web.json_response({"telemetry": telemetry_list})
@@ -5365,7 +5414,7 @@ class ReticulumMeshChat:
if r["physical_link"]
else None,
"updated_at": r["updated_at"],
- }
+ },
)
# upload offline map
@@ -5542,7 +5591,7 @@ class ReticulumMeshChat:
# setup session storage
# aiohttp_session.setup must be called before other middlewares that use sessions
-
+
# Ensure we have a valid 32-byte key for Fernet
try:
# First try decoding as base64 (since secrets.token_urlsafe produces base64)
@@ -7444,7 +7493,7 @@ class ReticulumMeshChat:
if lat is None or lon is None:
print(
- f"Cannot respond to telemetry request from {to_addr_hash}: No location set"
+ f"Cannot respond to telemetry request from {to_addr_hash}: No location set",
)
return

diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 0696cbbe..76085f50 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -1,14 +1,15 @@
from .announces import AnnounceDAO
from .config import ConfigDAO
+from .contacts import ContactsDAO
from .legacy_migrator import LegacyMigrator
from .messages import MessageDAO
from .misc import MiscDAO
from .provider import DatabaseProvider
+from .ringtones import RingtoneDAO
from .schema import DatabaseSchema
from .telemetry import TelemetryDAO
from .telephone import TelephoneDAO
from .voicemails import VoicemailDAO
-from .ringtones import RingtoneDAO
class Database:
@@ -23,6 +24,7 @@ class Database:
self.telemetry = TelemetryDAO(self.provider)
self.voicemails = VoicemailDAO(self.provider)
self.ringtones = RingtoneDAO(self.provider)
+ self.contacts = ContactsDAO(self.provider)
def initialize(self):
self.schema.initialize()

diff --git a/meshchatx/src/backend/database/config.py b/meshchatx/src/backend/database/config.py
index af863032..3180fe8d 100644
--- a/meshchatx/src/backend/database/config.py
+++ b/meshchatx/src/backend/database/config.py
@@ -18,13 +18,13 @@ class ConfigDAO:
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
else:
now = datetime.now(UTC)
-
+
# handle booleans specifically to ensure they are stored as "true"/"false"
if isinstance(value, bool):
value_str = "true" if value else "false"
else:
value_str = str(value)
-
+
self.provider.execute(
"""
INSERT INTO config (key, value, created_at, updated_at)

diff --git a/meshchatx/src/backend/database/contacts.py b/meshchatx/src/backend/database/contacts.py
new file mode 100644
index 00000000..5c216af1
--- /dev/null
+++ b/meshchatx/src/backend/database/contacts.py
@@ -0,0 +1,60 @@
+from .provider import DatabaseProvider
+
+
+class ContactsDAO:
+ def __init__(self, provider: DatabaseProvider):
+ self.provider = provider
+
+ def add_contact(self, name, remote_identity_hash):
+ self.provider.execute(
+ """
+ INSERT INTO contacts (name, remote_identity_hash)
+ VALUES (?, ?)
+ ON CONFLICT(remote_identity_hash) DO UPDATE SET
+ name = EXCLUDED.name,
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (name, remote_identity_hash),
+ )
+
+ def get_contacts(self, search=None, limit=100, offset=0):
+ if search:
+ return self.provider.fetchall(
+ """
+ SELECT * FROM contacts
+ WHERE name LIKE ? OR remote_identity_hash LIKE ?
+ ORDER BY name ASC LIMIT ? OFFSET ?
+ """,
+ (f"%{search}%", f"%{search}%", limit, offset),
+ )
+ return self.provider.fetchall(
+ "SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
+ (limit, offset),
+ )
+
+ def get_contact(self, contact_id):
+ return self.provider.fetchone(
+ "SELECT * FROM contacts WHERE id = ?",
+ (contact_id,),
+ )
+
+ def update_contact(self, contact_id, name=None, remote_identity_hash=None):
+ if name and remote_identity_hash:
+ self.provider.execute(
+ "UPDATE contacts SET name = ?, remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (name, remote_identity_hash, contact_id),
+ )
+ elif name:
+ self.provider.execute(
+ "UPDATE contacts SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (name, contact_id),
+ )
+ elif remote_identity_hash:
+ self.provider.execute(
+ "UPDATE contacts SET remote_identity_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (remote_identity_hash, contact_id),
+ )
+
+ def delete_contact(self, contact_id):
+ self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
+

diff --git a/meshchatx/src/backend/database/ringtones.py b/meshchatx/src/backend/database/ringtones.py
index 89d342e5..13178482 100644
--- a/meshchatx/src/backend/database/ringtones.py
+++ b/meshchatx/src/backend/database/ringtones.py
@@ -1,6 +1,8 @@
from datetime import UTC, datetime
+
from .provider import DatabaseProvider
+
class RingtoneDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -18,14 +20,14 @@ class RingtoneDAO:
now = datetime.now(UTC)
if display_name is None:
display_name = filename
-
+
# check if this is the first ringtone, if so make it primary
count = self.provider.fetchone("SELECT COUNT(*) as count FROM ringtones")["count"]
is_primary = 1 if count == 0 else 0
-
+
cursor = self.provider.execute(
"INSERT INTO ringtones (filename, display_name, storage_filename, is_primary, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
- (filename, display_name, storage_filename, is_primary, now, now)
+ (filename, display_name, storage_filename, is_primary, now, now),
)
return cursor.lastrowid
@@ -34,21 +36,21 @@ class RingtoneDAO:
if is_primary == 1:
# reset others
self.provider.execute("UPDATE ringtones SET is_primary = 0, updated_at = ?", (now,))
-
+
if display_name is not None and is_primary is not None:
self.provider.execute(
"UPDATE ringtones SET display_name = ?, is_primary = ?, updated_at = ? WHERE id = ?",
- (display_name, is_primary, now, ringtone_id)
+ (display_name, is_primary, now, ringtone_id),
)
elif display_name is not None:
self.provider.execute(
"UPDATE ringtones SET display_name = ?, updated_at = ? WHERE id = ?",
- (display_name, now, ringtone_id)
+ (display_name, now, ringtone_id),
)
elif is_primary is not None:
self.provider.execute(
"UPDATE ringtones SET is_primary = ?, updated_at = ? WHERE id = ?",
- (is_primary, now, ringtone_id)
+ (is_primary, now, ringtone_id),
)
def delete(self, ringtone_id):

diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 89d3e337..17fdbfc1 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
- LATEST_VERSION = 17
+ LATEST_VERSION = 18
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -238,6 +238,15 @@ class DatabaseSchema:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
+ "contacts": """
+ CREATE TABLE IF NOT EXISTS contacts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT,
+ remote_identity_hash TEXT UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
}
for table_name, create_sql in tables.items():
@@ -525,6 +534,23 @@ class DatabaseSchema:
)
""")
+ if current_version < 18:
+ self.provider.execute("""
+ CREATE TABLE IF NOT EXISTS contacts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT,
+ remote_identity_hash TEXT UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ self.provider.execute(
+ "CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)",
+ )
+ self.provider.execute(
+ "CREATE INDEX IF NOT EXISTS idx_contacts_remote_identity_hash ON contacts(remote_identity_hash)",
+ )
+
# Update version in config
self.provider.execute(
"""

diff --git a/meshchatx/src/backend/database/telemetry.py b/meshchatx/src/backend/database/telemetry.py
index 76b2c0f4..9f69505c 100644
--- a/meshchatx/src/backend/database/telemetry.py
+++ b/meshchatx/src/backend/database/telemetry.py
@@ -9,7 +9,7 @@ class TelemetryDAO:
self.provider = provider
def upsert_telemetry(
- self, destination_hash, timestamp, data, received_from=None, physical_link=None
+ self, destination_hash, timestamp, data, received_from=None, physical_link=None,
):
now = datetime.now(UTC).isoformat()

diff --git a/meshchatx/src/backend/database/voicemails.py b/meshchatx/src/backend/database/voicemails.py
index 43c5e4f7..0b8cdb85 100644
--- a/meshchatx/src/backend/database/voicemails.py
+++ b/meshchatx/src/backend/database/voicemails.py
@@ -37,7 +37,16 @@ class VoicemailDAO:
),
)
- def get_voicemails(self, limit=50, offset=0):
+ def get_voicemails(self, search=None, limit=50, offset=0):
+ if search:
+ return self.provider.fetchall(
+ """
+ SELECT * FROM voicemails
+ WHERE remote_identity_name LIKE ? OR remote_identity_hash LIKE ?
+ ORDER BY timestamp DESC LIMIT ? OFFSET ?
+ """,
+ (f"%{search}%", f"%{search}%", limit, offset),
+ )
return self.provider.fetchall(
"SELECT * FROM voicemails ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(limit, offset),

diff --git a/meshchatx/src/backend/ringtone_manager.py b/meshchatx/src/backend/ringtone_manager.py
index 95c09a5d..af26bbaa 100644
--- a/meshchatx/src/backend/ringtone_manager.py
+++ b/meshchatx/src/backend/ringtone_manager.py
@@ -1,20 +1,22 @@
import os
import shutil
import subprocess
+
import RNS
+
class RingtoneManager:
def __init__(self, config, storage_dir):
self.config = config
self.storage_dir = os.path.join(storage_dir, "ringtones")
-
+
# Ensure directory exists
os.makedirs(self.storage_dir, exist_ok=True)
-
+
# Paths to executables
self.ffmpeg_path = self._find_ffmpeg()
self.has_ffmpeg = self.ffmpeg_path is not None
-
+
if self.has_ffmpeg:
RNS.log(f"Ringtone: Found ffmpeg at {self.ffmpeg_path}", RNS.LOG_DEBUG)
else:

diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py
index ffa25c30..5f8837e7 100644
--- a/meshchatx/src/backend/voicemail_manager.py
+++ b/meshchatx/src/backend/voicemail_manager.py
@@ -325,6 +325,10 @@ class VoicemailManager:
try:
self.recording_sink = OpusFileSink(filepath)
+ # Ensure samplerate is set to avoid TypeError in LXST Opus codec
+ # which expects sink to have a valid samplerate attribute
+ self.recording_sink.samplerate = 48000
+
# Connect the caller's audio source to our sink
# active_call.audio_source is a LinkSource that feeds into receive_mixer
# We want to record what we receive.

diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue
index fcb1f9d6..e62bc34c 100644
--- a/meshchatx/src/frontend/components/call/CallOverlay.vue
+++ b/meshchatx/src/frontend/components/call/CallOverlay.vue
@@ -129,12 +129,12 @@
</div>
<!-- Controls -->
- <div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-3">
+ <div v-if="!isEnded && !wasDeclined" class="flex flex-wrap justify-center gap-2 px-2">
<!-- Mute Mic -->
<button
type="button"
:title="isMicMuted ? $t('call.unmute_mic') : $t('call.mute_mic')"
- class="p-3 rounded-full transition-all duration-200"
+ class="p-2.5 rounded-full transition-all duration-200"
:class="
isMicMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -142,14 +142,14 @@
"
@click="toggleMicrophone"
>
- <MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
+ <MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-5" />
</button>
<!-- Mute Speaker -->
<button
type="button"
:title="isSpeakerMuted ? $t('call.unmute_speaker') : $t('call.mute_speaker')"
- class="p-3 rounded-full transition-all duration-200"
+ class="p-2.5 rounded-full transition-all duration-200"
:class="
isSpeakerMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
@@ -157,7 +157,7 @@
"
@click="toggleSpeaker"
>
- <MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
+ <MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-5" />
</button>
<!-- Hangup -->
@@ -168,10 +168,10 @@
? $t('call.decline_call')
: $t('call.hangup_call')
"
- class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
+ class="p-2.5 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
@click="hangupCall"
>
- <MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
+ <MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
</button>
<!-- Send to Voicemail (if incoming) -->
@@ -179,10 +179,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.send_to_voicemail')"
- class="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
+ class="p-2.5 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
@click="sendToVoicemail"
>
- <MaterialDesignIcon icon-name="voicemail" class="size-6" />
+ <MaterialDesignIcon icon-name="voicemail" class="size-5" />
</button>
<!-- Answer (if incoming) -->
@@ -190,10 +190,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.answer_call')"
- class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
+ class="p-2.5 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
@click="answerCall"
>
- <MaterialDesignIcon icon-name="phone" class="size-6" />
+ <MaterialDesignIcon icon-name="phone" class="size-5" />
</button>
</div>
</div>
@@ -271,6 +271,7 @@ export default {
default: false,
},
},
+ emits: ["hangup"],
data() {
return {
isMinimized: false,

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 2c8343d7..96e1177e 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -31,6 +31,17 @@
>{{ unreadVoicemailsCount }}</span
>
</button>
+ <button
+ :class="[
+ activeTab === 'contacts'
+ ? 'border-blue-500 text-blue-600 dark:text-blue-400'
+ : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:border-gray-300',
+ ]"
+ class="py-2 px-4 border-b-2 font-medium text-sm transition-all"
+ @click="activeTab = 'contacts'"
+ >
+ Contacts
+ </button>
<button
:class="[
activeTab === 'ringtone'
@@ -215,16 +226,16 @@
</div>
<!-- actions -->
- <div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-6">
+ <div v-if="activeCall" class="flex flex-wrap justify-center gap-4 mt-8 mb-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.answer_call')"
type="button"
- class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
+ class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
- <MaterialDesignIcon icon-name="phone" class="size-5" />
+ <MaterialDesignIcon icon-name="phone" class="size-4" />
<span>{{ $t("call.accept") }}</span>
</button>
@@ -233,10 +244,10 @@
v-if="activeCall.is_incoming && activeCall.status === 4"
:title="$t('call.send_to_voicemail')"
type="button"
- class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
+ class="inline-flex items-center gap-x-2 rounded-2xl bg-blue-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-blue-500 transition-all duration-200"
@click="sendToVoicemail"
>
- <MaterialDesignIcon icon-name="voicemail" class="size-5" />
+ <MaterialDesignIcon icon-name="voicemail" class="size-4" />
<span>{{ $t("call.send_to_voicemail") }}</span>
</button>
@@ -248,10 +259,10 @@
: $t('call.hangup_call')
"
type="button"
- class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-5 py-3 text-base font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
+ class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
- <MaterialDesignIcon icon-name="phone-hangup" class="size-5 rotate-[135deg]" />
+ <MaterialDesignIcon icon-name="phone-hangup" class="size-4 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4
? $t("call.decline")
@@ -403,6 +414,21 @@
<!-- Voicemail Tab -->
<div v-if="activeTab === 'voicemail'" class="flex-1 flex flex-col">
+ <div class="mb-4">
+ <div class="relative">
+ <input
+ v-model="voicemailSearch"
+ type="text"
+ placeholder="Search voicemails..."
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="onVoicemailSearchInput"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ </div>
+
<div v-if="voicemails.length === 0" class="my-auto text-center">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
<MaterialDesignIcon icon-name="voicemail" class="size-12 text-gray-400" />
@@ -530,6 +556,114 @@
</div>
</div>
+ <!-- Contacts Tab -->
+ <div v-if="activeTab === 'contacts'" class="flex-1 flex flex-col">
+ <div class="mb-4 flex gap-2">
+ <div class="relative flex-1">
+ <input
+ v-model="contactsSearch"
+ type="text"
+ placeholder="Search contacts..."
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ @input="onContactsSearchInput"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ <button
+ type="button"
+ class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-colors flex items-center gap-2"
+ @click="openAddContactModal"
+ >
+ <MaterialDesignIcon icon-name="plus" class="size-5" />
+ Add
+ </button>
+ </div>
+
+ <div v-if="contacts.length === 0" class="my-auto text-center">
+ <div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-full inline-block mb-4">
+ <MaterialDesignIcon icon-name="account-multiple" class="size-12 text-gray-400" />
+ </div>
+ <h3 class="text-lg font-medium text-gray-900 dark:text-white">No Contacts</h3>
+ <p class="text-gray-500 dark:text-zinc-400">Add contacts to quickly call them.</p>
+ </div>
+
+ <div v-else class="space-y-4">
+ <div
+ class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
+ >
+ <ul class="divide-y divide-gray-100 dark:divide-zinc-800">
+ <li
+ v-for="contact in contacts"
+ :key="contact.id"
+ class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
+ >
+ <div class="flex items-center space-x-3">
+ <div class="shrink-0">
+ <LxmfUserIcon
+ v-if="contact.remote_icon"
+ :icon-name="contact.remote_icon.icon_name"
+ :icon-foreground-colour="contact.remote_icon.foreground_colour"
+ :icon-background-colour="contact.remote_icon.background_colour"
+ class="size-10"
+ />
+ <div
+ v-else
+ class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
+ >
+ <MaterialDesignIcon icon-name="account" class="size-6" />
+ </div>
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center justify-between">
+ <p class="text-sm font-bold text-gray-900 dark:text-white truncate">
+ {{ contact.name }}
+ </p>
+ <div class="flex items-center gap-1">
+ <button
+ type="button"
+ class="p-1.5 text-gray-400 hover:text-blue-500 transition-colors"
+ @click="openEditContactModal(contact)"
+ >
+ <MaterialDesignIcon icon-name="pencil" class="size-4" />
+ </button>
+ <button
+ type="button"
+ class="p-1.5 text-gray-400 hover:text-red-500 transition-colors"
+ @click="deleteContact(contact.id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="size-4" />
+ </button>
+ </div>
+ </div>
+ <div class="flex items-center justify-between mt-1">
+ <span
+ class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate"
+ :title="contact.remote_identity_hash"
+ >
+ {{ formatDestinationHash(contact.remote_identity_hash) }}
+ </span>
+ <button
+ type="button"
+ class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
+ @click="
+ destinationHash = contact.remote_identity_hash;
+ activeTab = 'phone';
+ call(destinationHash);
+ "
+ >
+ Call
+ </button>
+ </div>
+ </div>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
<!-- Ringtone Tab -->
<div v-if="activeTab === 'ringtone' && config" class="flex-1 space-y-6">
<div
@@ -933,6 +1067,16 @@ export default {
editingRingtoneId: null,
editingRingtoneName: "",
elapsedTimeInterval: null,
+ voicemailSearch: "",
+ contactsSearch: "",
+ contacts: [],
+ isContactModalOpen: false,
+ editingContact: null,
+ contactForm: {
+ name: "",
+ remote_identity_hash: "",
+ },
+ searchDebounceTimeout: null,
};
},
computed: {
@@ -956,6 +1100,7 @@ export default {
this.getStatus();
this.getHistory();
this.getVoicemails();
+ this.getContacts();
this.getVoicemailStatus();
this.getRingtones();
this.getRingtoneStatus();
@@ -971,6 +1116,7 @@ export default {
this.historyInterval = setInterval(() => {
this.getHistory();
this.getVoicemails();
+ this.getContacts();
}, 10000);
// update elapsed time every second
@@ -1204,13 +1350,78 @@ export default {
},
async getVoicemails() {
try {
- const response = await window.axios.get("/api/v1/telephone/voicemails");
+ const response = await window.axios.get("/api/v1/telephone/voicemails", {
+ params: { search: this.voicemailSearch },
+ });
this.voicemails = response.data.voicemails;
this.unreadVoicemailsCount = response.data.unread_count;
} catch (e) {
console.log(e);
}
},
+ onVoicemailSearchInput() {
+ if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
+ this.searchDebounceTimeout = setTimeout(() => {
+ this.getVoicemails();
+ }, 300);
+ },
+ async getContacts() {
+ try {
+ const response = await window.axios.get("/api/v1/telephone/contacts", {
+ params: { search: this.contactsSearch },
+ });
+ this.contacts = response.data;
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ onContactsSearchInput() {
+ if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
+ this.searchDebounceTimeout = setTimeout(() => {
+ this.getContacts();
+ }, 300);
+ },
+ openAddContactModal() {
+ this.editingContact = null;
+ this.contactForm = { name: "", remote_identity_hash: "" };
+ const name = prompt("Enter contact name:");
+ if (!name) return;
+ const hash = prompt("Enter identity hash:");
+ if (!hash) return;
+ this.saveContact({ name, remote_identity_hash: hash });
+ },
+ openEditContactModal(contact) {
+ this.editingContact = contact;
+ const name = prompt("Edit contact name:", contact.name);
+ if (!name) return;
+ const hash = prompt("Edit identity hash:", contact.remote_identity_hash);
+ if (!hash) return;
+ this.saveContact({ id: contact.id, name, remote_identity_hash: hash });
+ },
+ async saveContact(contact) {
+ try {
+ if (contact.id) {
+ await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, contact);
+ ToastUtils.success("Contact updated");
+ } else {
+ await window.axios.post("/api/v1/telephone/contacts", contact);
+ ToastUtils.success("Contact added");
+ }
+ this.getContacts();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || "Failed to save contact");
+ }
+ },
+ async deleteContact(contactId) {
+ if (!confirm("Are you sure you want to delete this contact?")) return;
+ try {
+ await window.axios.delete(`/api/v1/telephone/contacts/${contactId}`);
+ ToastUtils.success("Contact deleted");
+ this.getContacts();
+ } catch {
+ ToastUtils.error("Failed to delete contact");
+ }
+ },
async generateGreeting() {
this.isGeneratingGreeting = true;
try {
@@ -1259,7 +1470,9 @@ export default {
},
async playVoicemail(voicemail) {
if (this.playingVoicemailId === voicemail.id) {
- this.audioPlayer.pause();
+ if (this.audioPlayer) {
+ this.audioPlayer.pause();
+ }
this.playingVoicemailId = null;
return;
}
@@ -1270,11 +1483,25 @@ export default {
this.playingVoicemailId = voicemail.id;
this.audioPlayer = new Audio(`/api/v1/telephone/voicemails/${voicemail.id}/audio`);
- this.audioPlayer.play();
+
+ this.audioPlayer.addEventListener("error", (e) => {
+ console.error("Audio player error:", e);
+ ToastUtils.error(this.$t("call.failed_to_play_voicemail") || "Failed to load voicemail audio");
+ this.playingVoicemailId = null;
+ this.audioPlayer = null;
+ });
+
this.audioPlayer.onended = () => {
this.playingVoicemailId = null;
};
+ try {
+ await this.audioPlayer.play();
+ } catch (e) {
+ console.error("Audio play failed:", e);
+ this.playingVoicemailId = null;
+ }
+
// Mark as read
if (!voicemail.is_read) {
try {

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 84c0777a..9f059d98 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -108,6 +108,11 @@
<MaterialDesignIcon icon-name="open-in-new" class="w-4 h-4" />
</IconButton>
+ <!-- share contact button -->
+ <IconButton title="Share Contact" @click="openShareContactModal">
+ <MaterialDesignIcon icon-name="notebook-outline" class="w-4 h-4" />
+ </IconButton>
+
<!-- close button -->
<IconButton title="Close" @click="close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
@@ -119,6 +124,64 @@
</div>
</div>
+ <!-- Share Contact Modal -->
+ <div
+ v-if="isShareContactModalOpen"
+ class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
+ @click.self="isShareContactModalOpen = false"
+ >
+ <div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white">Share Contact</h3>
+ <button
+ type="button"
+ class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
+ @click="isShareContactModalOpen = false"
+ >
+ <MaterialDesignIcon icon-name="close" class="size-6" />
+ </button>
+ </div>
+ <div class="p-6">
+ <div class="mb-4">
+ <div class="relative">
+ <input
+ v-model="contactsSearch"
+ type="text"
+ placeholder="Search contacts..."
+ class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
+ />
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
+ </div>
+ </div>
+ </div>
+ <div class="max-h-64 overflow-y-auto space-y-2">
+ <button
+ v-for="contact in filteredContacts"
+ :key="contact.id"
+ type="button"
+ class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
+ @click="shareContact(contact)"
+ >
+ <div
+ class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
+ >
+ <MaterialDesignIcon icon-name="account" class="size-6" />
+ </div>
+ <div class="min-w-0">
+ <div class="font-bold text-gray-900 dark:text-white truncate">
+ {{ contact.name }}
+ </div>
+ <div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
+ {{ contact.remote_identity_hash }}
+ </div>
+ </div>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- chat items -->
<div
id="messages"
@@ -885,6 +948,10 @@ export default {
autoScrollOnNewMessage: true,
composeAddress: "",
+ isShareContactModalOpen: false,
+ contacts: [],
+ contactsSearch: "",
+
isRecordingAudioAttachment: false,
audioAttachmentMicrophoneRecorder: null,
audioAttachmentMicrophoneRecorderCodec: null,
@@ -912,6 +979,13 @@ export default {
};
},
computed: {
+ filteredContacts() {
+ if (!this.contactsSearch) return this.contacts;
+ const s = this.contactsSearch.toLowerCase();
+ return this.contacts.filter(
+ (c) => c.name.toLowerCase().includes(s) || c.remote_identity_hash.toLowerCase().includes(s)
+ );
+ },
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
},
@@ -1612,6 +1686,27 @@ export default {
// do nothing if failed to delete message
}
},
+ async openShareContactModal() {
+ try {
+ const response = await window.axios.get("/api/v1/telephone/contacts");
+ this.contacts = response.data;
+
+ if (this.contacts.length === 0) {
+ ToastUtils.info("No contacts found in telephone");
+ return;
+ }
+
+ this.isShareContactModalOpen = true;
+ } catch (e) {
+ console.error(e);
+ ToastUtils.error("Failed to load contacts");
+ }
+ },
+ async shareContact(contact) {
+ this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
+ this.isShareContactModalOpen = false;
+ await this.sendMessage();
+ },
async sendMessage() {
// do nothing if can't send message
if (!this.canSendMessage) {

diff --git a/meshchatx/src/version.py b/meshchatx/src/version.py
index 11a3bd4a..12e5a76e 100644
--- a/meshchatx/src/version.py
+++ b/meshchatx/src/version.py
@@ -3,4 +3,4 @@ Auto-generated helper so Python tooling and the Electron build
share the same version string.
"""
-__version__ = '3.0.0'
+__version__ = '3.1.0'


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────